Global Illumination - Sixth 3D

Table of Contents

<- Back to index

1. What global illumination adds

Plain per-polygon shading lights every polygon with one flat color: no shadows, and surfaces that receive no direct light stay uniformly dark. A room corner reads as a flat silhouette instead of a corner.

gi-flat.png

Sixth 3D can optionally compute global illumination progressively on background CPU threads: shadows appear, light pools under lamps with smooth falloff, and colored light bleeds — a red sofa tints the floor next to it red. All of it converges gradually over the first seconds of a scene, then idles.

Same camera, same house: flat shading (top) versus converged GI (below). Note the soft shadow of the partition wall, the lamp glow on the ceiling, and the subtle color variation across the floor.

gi-converged.png

2. The big idea: GI off the render path

Ray tracing is far too slow to run per frame in a software renderer, so it doesn't: the render loop never traces a single ray. Painting a lightmapped triangle is an ordinary texture lookup, exactly as fast as any textured polygon.

All the expensive work happens on dedicated low-priority worker threads that continuously refine per-surface lighting values. Whenever the values have improved enough, the workers regenerate each triangle's composite texture (baseColor x total lighting) into a back buffer and swap it in atomically — painters never see a half-updated texture.

scene snapshot triangles + lights + BVH GI workers Monte Carlo sweeps lightmaps per-texel indirect + shadow bits composite baseColor x light double-buffered swap painter plain texture lookup bounce reads last sweep's estimate zero ray casting on render threads

Because painters only read finished textures, frame rate is completely decoupled from GI quality: you can crank lightmap resolution up and the only cost is CPU time on the worker threads, not frame time.

3. Lightmaps: a texture per triangle

Flat shading can only color a polygon uniformly — shadows and gradients need resolution inside the polygon. Every lightmapped triangle therefore owns a small generated texture, its lightmap, whose texels map onto the triangle surface by an affine rule:

invalid half (u+v > 1) filled from neighbors so sampling near the hypotenuse stays clean one texel = one surface patch, sampled at center a (u=0, v=0) b (u=1, v=0) c (u=0, v=1) e1 = b − a e2 = c − a world(u,v) = a + e1·u + e2·v texels = edge / unitsPerTexel every texel owns a fixed patch of the triangle — shadows and gradients live INSIDE the surface

The triangle's UVs are pinned to (0,0), (1,0), (0,1), so the valid texel region is the half where u+v <= 1; the other half of the square texture is flood-filled from valid neighbors so that nearest sampling near the hypotenuse never picks up garbage.

Each texel stores two things, both written only by GI threads:

  • Indirect irradiance (RGB floats) — the accumulated bounced light.
  • Per-light visibility bits — whether the last shadow ray from this texel reached each lamp (cached so later queries cost nothing).

Resolution is set in world units per texel (BspCompositeShape.setLightmapUnitsPerTexel(), default 12): a 100-unit wall cell gets an 8x8 lightmap. Halving the units quadruples the tracing work.

What you trace is what you see: the composite texture the painter samples is the lightmap itself, at native texel resolution — there is no upscaling step. Shadow-edge smoothness comes from tracing at finer resolution, never from interpolation. (An earlier bilinear-upscaling pass produced visibly artificial results and was removed; finer texels cost more CPU on the GI threads but look right.)

4. One sample: a shadow ray and a bounce ray

The work list is flat: one item per lightmap texel (and one per plain polygon, see below). Worker threads walk it round-robin, and every visit to a texel casts exactly two rays from the texel's world position, nudged slightly off the surface along the normal:

  1. Shadow ray toward a lamp. Answers visible/occluded, cached in the texel's visibility bits. On a texel's first visit all lamps are tested at once, so direct light and hard shadows appear after a single sweep instead of trickling in lamp by lamp; later visits re-test one lamp at a time, round-robin.
  2. Bounce ray in a random cosine-weighted direction around the normal. Wherever it lands (point Q), the sample reads Q's direct lighting — using Q's cached shadow bits, no new shadow rays — plus Q's current indirect estimate, and blends the sum into the texel's own indirect value.
surface P normal cosine-weighted hemisphere Q light shadow ray: clear occluder blocked at Q: direct light (cached shadow bits) + Q's current indirect estimate P's target = (albedo / π) x (direct + indirect) blended in with an exponential moving average one sample per texel per visit: one shadow ray + one bounce ray — bounce light ripples deeper every sweep

Reading Q's current indirect estimate instead of recursing is what makes bounce light propagate: sweep 1 learns "Q is directly lit", sweep 2 learns "P sees a lit Q", sweep 3 learns "R sees a lit P"… Light ripples one surface deeper with every sweep, with no recursion limit and no exponential ray explosion. The 1/pi diffuse gain keeps the feedback loop from diverging: without it the indirect term amplifies itself and the scene saturates to white.

5. Progressive convergence

Monte Carlo samples are noisy, so blending happens at two nested levels, both exponential moving averages:

  1. Inner, per sample: each texel blends every new bounce-ray result into its indirect estimate with a constant weight (alpha = 0.15, mode fixed). Every ray hit stays equally intensive forever — an unlit area fades to darkness at the same rate a lit area brightens. (The old adaptive mode, which decays alpha with sample count, is still available; see the knobs below.)
  2. Outer, per composite update: the value that reaches the screen is a second EMA over the COMPLETE sum ambient + direct + indirect. The texture can only move e3d.gi.compositeAlpha (default 0.2) of the remaining distance per 500 ms update — so direct light, shadows and bounce light all glide in together over a few seconds, and no single-frame jump is possible by construction.

The world starts at a uniform medium irradiance (e3d.gi.initialIrradiance, default 128): the scene is visible from the very first frame, then lit areas brighten and unlit areas sink to darkness as the workers sweep — lights and shadows gradually become distinguished instead of the old pitch-black start with a sudden flash once the first sweep landed:

gi-start.png

Consequences of the design:

  • Hysteresis is free: when a lamp moves or geometry changes, old light fades out gradually instead of popping — the same pair of EMAs that accumulates light also drains it.
  • Convergence detection: per-sample deltas are pure noise (and with a constant alpha they never settle), so the system watches the average per-texel movement of the on-screen estimate; after five composite updates below e3d.gi.calmThreshold (default 1.0 light unit) the workers drop to a low duty cycle (~50% of sweep time, capped) instead of burning CPU. Any scene or light change rebuilds the snapshot and restarts full-speed tracing.
  • Despeckle: at composite time the indirect channel is blended 50/50 with the mean of its valid 4-neighbors, killing single-texel Monte Carlo spikes without blurring real gradients.
  • Composite cadence: textures regenerate at most every 500 ms — one atomic swap per triangle, invisible to painters.

6. Plain polygons get GI too

Surfaces that are not lightmapped (ordinary SolidPolygon with shading enabled) still benefit, at per-polygon resolution, through the GiLightProvider interface that GlobalIllumination installs into the LightingManager:

  • isLightVisible(polygon, light) answers from cached shadow bits — direct-light shadows fade in on flat-shaded geometry.
  • addIndirectLight(polygon, baseColor, result) adds the polygon's bounced-light estimate into the flat-shaded color.

Both are called from parallel render-pool threads, so they only read volatile caches — never trace.

7. Enabling GI

// Per-texel lightmaps on BSP-compiled geometry (the House demo setup):
BspCompositeShape house = new BspCompositeShape();
house.setLightmappingEnabled(true);
house.setLightmapUnitsPerTexel(3.0);   // fine texels: quality from traced rays

// Start the workers (2 threads by default; more converge faster):
viewPanel.enableGlobalIllumination(4);

Tuning knobs (system properties):

Property Default Effect
e3d.gi.alphaMode fixed adaptive decays the inner EMA alpha with sample count
e3d.gi.alphaFloor 0.08 adaptive-mode floor; higher adapts faster but noisier
e3d.gi.compositeAlpha 0.2 outer EMA: fade speed of the on-screen estimate per 500 ms update
e3d.gi.initialIrradiance 128 uniform medium start (0..255 light units)
e3d.gi.calmThreshold 1.0 convergence: avg estimate movement (light units)
e3d.gi.despeckle true neighbor-smoothing of indirect at composite time
e3d.gi.debug false sweep statistics to stdout
e3d.gi.dumpLightmaps (unset) dump composite lightmaps as PNGs to the given dir

8. Limitations

  • Diffuse light only — no specular bounce, no caustics.
  • Polygon vertices are traced in composite-local space; scenes that put non-identity transforms on composites are traced incorrectly.
  • The bounce estimate is one ray deep per sample — correctness comes from sweep-over-sweep propagation, so deeply indirect corners take several sweeps to brighten.

Created: 2026-09-08 ti 00:46

Validate